Auth : OAuth는 인가 프로토콜이다(RFC 6749)
OAuth 2.0 Protocol with RFC
들어가기 앞서 OAuth 2.0의 플로우를 소개한다.
+--------+ +---------------+
| |--(A)- Authorization Request ->| Resource |
| | | Owner |
| |<-(B)-- Authorization Grant ---| |
| | +---------------+
| |
| | +---------------+
| |--(C)-- Authorization Grant -->| Authorization |
| Client | | Server |
| |<-(D)----- Access Token -------| |
| | +---------------+
| |
| | +---------------+
| |--(E)----- Access Token ------>| Resource |
| | | Server |
| |<-(F)--- Protected Resource ---| |
+--------+ +---------------+
위는 OAuth 2.0의 Flow다. 이 플로우에서 플레이어들을 간략히 설명하면
- Resource Owner : 사용자
- Client : 내 웹/앱
- Authorization Server : Google, Kakao 같은 Provider
- Resource Server : Google Email API, Kakao Map API
그리고 Flow를 간략히 설명하면 다음과 같다.
- (A) Authorization Request : 앱이 사용자에게 이 권한 허락할래? 묻는 단계
- 보통 Authorization Server를 거쳐서 로그인·동의 화면으로 이동
- (B) Authorization Grant 획득 : 승인 코드(code 등)를 받는 단계
- 사용자가 허용하면, Authorization Server는 Authorization Grant(권한 부여 증명서)를 클라이언트에게 발급한다. 이건 실제 토큰은 아니고 토큰을 받을 자격 증명이다.
- Auth.js 사용하면 이건 알아서 진행한다.
- (C) Access Token 요청 : Provider의 로그인 서버에 증명서 주고 토큰 달라고 하는 단계
- 클라이언트는 Authorization Server에 인증하고, 방금 받은 Grant를 제시해 Access Token을 요청한다.
- (D) Access Token 발급 : 서버가 토큰을 발급하는 단계
- Authorization Server가 클라이언트를 인증하고 Grant의 유효성을 검사한 뒤, Access Token(및 Refresh Token 등)을 발급한다.
- (E) Resource Request : 토큰으로 실제 API 호출하는 단계
- 클라이언트는 받은 Access Token을 Authorization 헤더에 담아 Resource Server에 요청한다.
- 예를 들어, 구글 캘린더 API를 요청하는 것
- (F) Resource Server 검증 : 토큰 확인 후 데이터 응답
- Resource Server가 Access Token을 검증하고, 유효하면 요청된 보호 리소스를 반환한다.
우리가 Auth.js를 사용하면 A->D 까지 자동화 되는 것이다. Auth.js는 로그인을 클릭하면 카카오 로그인 페이지로 redirect 해주고 Grant를 획득한 후 AccessToken을 요청받고 내려주는 것 까지 쉽게 해준다.
RFC 문서
OAuth는 인증이 아니라 인가 프로토콜이다.
OAuth는 사용자가 가진 자원(Resource)에 대한 접근 권한을 제3자에게 위임하는 것이지, 사용자의 신원을 확인하는 것이 아니기 때문에 OAuth 2.0은 로그인(인증) 프로토콜이 아니라, 권한을 얻는(인가) 프로토콜이다.
{
"access_token": "ya29.A0AR...",
"scope": "calendar.readonly email",
"expires_in": 3600,
"token_type": "Bearer"
}
OAuth로 Provider 로그인을 실행하면 위와 같은 JSON이 온다.
accessToken이 오니까 인증 아니야? 라고 생각할 수 있지만.
scope에는 접근권한을 명시하고 있는 것 처럼, accessToken으로 어느 리소스에 접근할 수 있는가를 알려주는 것이다.
즉, OAuth는 사용자가 가진 자원(Resource)에 대한 접근 권한을 제3자에게 위임하는 것”이지, “사용자의 신원을 확인하는 것”이 주요 목적은 아니기 때문에
OAuth 2.0은 로그인(인증) 프로토콜이 아니라, 권한을 얻는(인가) 프로토콜이다.
RFC문서에서도 OAuth 2.0을 "Authorization framework"라고 표현한다.
RFC 문서
The OAuth 2.0 authorization framework enables a third-party application to obtain limited access to an HTTP service, either on behalf of a resource owner by orchestrating an approval interaction between the resource owner and the HTTP service, or by allowing the third-party application to obtain access on its own behalf
하지만 유저가 인증을 하긴 하잖아 ? 그건 OAuth 2.0에 속한 프로토콜이 아닌것인가?
RFC 6749은 ‘Authorization Server’가 “누구인지 확인한 다음 Access Token을 주는 과정”을 설명하지 않는다. 단지 Access Token을 발급하고, 그 토큰으로 자원 접근을 허용하는 규칙만 정의한다. 즉, 로그인 과정은 “Authorization Server의 구현 세부사항”일 뿐, 프로토콜 수준에서 다루지 않는다.
그렇기 때문에, OAuth Provider의 AccessToken을 서비스의 Token으로 쓰면 부적절하다.
구글 Access Token은 “Google API 접근용”이지, 우리 서비스용 인증 토큰이 아니다. scope이 구글 자원(email, calendar 등)에 한정되어 있다. 제3자 서비스가 Google Token을 직접 저장/전송하면 위험하다. (토큰 탈취 시 Google API 접근 가능하니까) 또, 우리가 토큰을 강제로 만료시키거나 세션을 끊을 방법이 없다.
따라서, OAuth Provider의 토큰은 “사용자 인증 수단”이지, 서비스 세션으로 쓰는 건 위험해.
RFC 문서
This specification is designed for use with HTTP ([RFC2616]). The use of OAuth over any protocol other than HTTP is out of scope.
번외 : Callback URL과 State Parameter
별다른 이야기는 없고, 트러블 슈팅한 이야기를 적어보려 한다.
레퍼럴 시스템 구현시 피추천인 로그인시 추천인의 초대코드를 OAuth 하는 동안 유지해야했다.
회원가입 처리할 때 기억해둔 추천인 코드를 전송하여 보상하기 위함이었다.
로그인 처리는 Auth.js로 구현되어 있어, /api/auth 에서 모든 로그인,회원가입 로직이 처리되므로 로컬스토리지로 기억하긴 어려웠다.
(쿠키로 해도 아래 문제들을 해결이 되었을 것이지만... 계속 설명한다.)
처음에는 쉽게 callbackUrl에 referral="\{inviteCode : 'asdasd'}\" 라는 쿼리스트링을 붙여서 데이터를 유지하려고 했지만
로그인을 하고 돌아올 때 callbackUrl에 다른 쿼리스트링이 잘려서 오는 문제가 있었다.
문제를 분석하던 중, RFC 6749 문서에서 언급된 state 파라미터가
“요청과 응답을 연결하고 CSRF를 방지하기 위한 안전한 데이터 전달 수단”임을 알게 되었다.
이에 callbackUrl 대신 state 값을 활용하도록 수정했다.
(참고로 auth.js의 login 함수의 두번째 인자가 state 파라미터를 담는 곳이다.)
또 여기서, Provider마다 state를 돌려줄 때 인코딩 방식이 달랐다.
카카오는 직렬화된 JSON을 그대로 응답해줬고 네이버는 HTML 엔티티 ({"inviteCode":"abcd"})로 응답해줬다.
그래서 Base64로 인코딩한 문자열을 State에 담으려고 했으나,
Base64로 인코딩 될 때 +, /, =와 같은 문자열도 생성되어, 로그인후 돌아오면 = 이후로 데이터가 잘리는 현상이 발생했다.
따라서 URL-SAFE한 encodeUriComponent로 인코딩하여 문제를 해결할 수 있었다.
참고하시라..
References
- https://datatracker.ietf.org/doc/html/rfc6749